拆虽不易,合则更难!持续集成是微服务化的“基石”
在很多微服务化的文章中,很少会把持续集成放在第一篇,因为大多数的文章都会讲如何拆的问题,例如拆的粒度,拆的时机,拆的方式。
持续集成对于微服务的意义:拆之前要先解决合的问题
为什么需要拆呢?因为这是人类处理问题的本质方式:将一个大的复杂问题,变成很多个小问题解决。
所以当一个系统复杂到一定程度,当维护一个系统的人数多到一定程度,解决问题的难度和沟通成本大大提高,因而需要拆成很多个工程,拆成很多个团队,分而治之。
然而当每个子团队将子问题解决了,整个系统的问题就解决了么?你可以想象你将一辆整车拆成零件,然后再组装起来的过程,你就可以想象拆虽然不容易,合则更难,需要各种标准,各种流水线,才能将零件组装成为车。
我们先来回顾一下拆的过程。
最初的应用大多数是一个单体应用。
一个 Java 后端,后面跟一个数据库,基本上就搞定了。
随着系统复杂度的增加,首先 Java 程序需要做的是纵向的拆分。
首先最外面是一个负载均衡,接着是接入的 Nginx,做不同服务的路由。
不同的服务拆成独立的进程,独立部署,每个服务使用自己的数据库和缓存,解决数据库和缓存的单点瓶颈。
数据库使用一主多从的模式,进行读写分离,主要针对读多写少的场景。
为了承载更多的请求,设置缓存层,将数据缓存到 Memcached 或者 Redis 中,增加命中率。
当然还有些跨服务的查询,或者非结构化数据的查询,引入搜索引擎,比关系型数据库的查询速度快很多。
在高并发情况下,仅仅纵向拆分还不够,因而需要做真正的服务化。
一个服务化的架构如下图所示:
首先是接入层,这一层主要实现 API 网关和动态资源和静态资源的分离及缓存,并且可以在这一层做整个系统的限流。
接下来是 Web 层,也就是 controller,提供最外层的 API,是对外提供服务的一层。
下面是组合服务层,有时候被称为编排层,Compose 层,是实现复杂逻辑的一层。
下面是基础服务层,是提供原子性的基本的逻辑的一层,它下面是缓存,数据库。
服务之间需要治理,需要相互发现,所以一般会有 Dubbo 或者 Spring Cloud 一样的框架。
对所有的服务,都应该有监控告警,及时发现异常,并自动修复或者告警运维手动修复。
对于所有的服务的日志,应该有相同的格式,收集到一起,称为日志中心,方便发现错误的时候,在统一的一个地方可以 Debug。
对于所有的服务的配置,有统一的管理的地方,称为配置中心,可以通过修改配置中心,下发配置,对于整个集群进行配置的修改,例如打开熔断或者降级开关等。
通过简单的描述,大家可以发现,从一个简单的单体应用,变成如此复杂的微服务架构,除了关心怎么拆的问题,还必须关注:
如何控制拆的风险
如何保证代码质量
如何保证功能不变,不引入新的 Bug
答案当然就是集成,从一开始就集成,并且不断的集成,反复的将拆分的模块重新组合,看看是否能够顺利组合起来,并且保证功能的不变。
要是不没事儿就组合一下,天知道几个月以后还能不能合的起来。
别忘了程序是人写的,你和你媳妇长时间不沟通都谈不上默契,别说两个程序员了。
持续集成就是不断的尝试在一起
集成就是在一起,如下图:
为什么需要一个统一的代码仓库 Git 来做代码管理呢?是为了代码集成在一起。
为什么需要进行构建 build 呢?就是代码逻辑需要集成在一起,编译不出错。
为什么要单元测试呢?一个模块的功能集成在一起能够正确工作。
为什么需要联调测试 Staging 环境呢?需要将不同模块之间集成在一起,在一个类生产的环境中进行测试。
最终才是部署到生产环境中,将所有人分开做的工作才算真正的合在了一起。
持续集成就是制定一系列流程,或者一个系列规则,将需要在一起的各个层次规范起来,方便大家在一起,强迫大家在一起。
持续集成、持续交付、持续部署、敏捷开发、DevOps 都有啥关系?
这些概念都容易混淆,他们之间是什么关系呢?如下图:
敏捷开发 Agile 是一种开发流程,是一种快速迭代的开发流程,每个开发流程非常短,长到一个月,短到两个星期,就会是一个周期,在这个周期中,每天都要开会同步,每天都要集成。
正是因为周期短,才需要持续的做这件事情,如果一个开发周期长达几个月,则不需要持续的集成。
最后留几个星期的集成时间一起做也是可以的,但是这样就不能达到互联网公司的快速迭代,也是我们常常看到传统公司的做法。
持续集成往往指对代码的提交,构建,测试的过程,也就是上述的在一起的过程。
持续交付是指将集成好的交付物,例如 war、jar 或者容器镜像,部署在联调环境,或者预发环境的过程。
持续部署是指将交付物持续部署在生产环境的过程。
我们常说 CI/CD,CD 有时候指的是 Delivery 交付,有的是指 Deployment 部署,对于非生产环境,自动部署是没有问题的,对于生产环境,往往还是需要有专人来进行更为严肃的部署过程,不会完全的自动化。
接下来就是 DevOps,DevOps 不只是 CI/CD,除了技术和流程,还包含文化。
例如容器化带来的一个巨大的转变是,原来只有运维关心环境的部署,无论是测试环境,还是生产环境,都是运维搞定的,而容器化之后,需要开发自己写 Dockerfile,自己关心环境的部署。
因为微服务之后,模块太多了,让少数的运维能够很好的管理所有的服务,压力大,易出错,然而开发往往分成很多的团队,每个模块自己关心自己的部署,则不易出错。
这就需要运维一部分的工作让研发来做,需要研发和运维的打通,如果公司没有这个文化,研发的老大说我们不写 Dockerfile,则 DevOps 是搞不定的。
从一个持续集成的日常,看上述的几个概念如何实践
如上图,这是一个持续集成的流程,但是运行起来更加的复杂。
首先,项目开发的流程使用的是 Agile,用常见的 scrum 为例子。
每天早上第一件事情,就是开站会 standup meeting,为什么要站着呢?
因为时间不能太长,微服务的一个模块,大概需要 5-9 人的团队规模,如果团队规模太大了,说明服务应该进行拆分了,这个团队规模,是能够保证比较短的时间之内过完昨天的状态的。
一定要大家一起开,而不要线下去更新 Jira,虽然看起来一样,但是执行起来完全不一样。
只有大家一起开,一起看燃尽图,一起说我昨天做了什么,今天打算做什么,有什么阻碍,才能够让大家都了解情况,不要期望大家会去看别人的 Jira,经验告诉你,不会的。
而且这个站会对于开发是比较大的压力,例如你的一个功能 block 了依赖方的开发,在会议上会暴露出来,大家都知道这件事情了,一天 block,两天 block,第三天你都不好意思去说了,这会强迫你将大任务细化。
比如原来写 1 周干一件什么事情,写成小时级别,这样每天你都有的说,昨天完成了一个 task,而不是周会只在那里说干同样一件事情。
而且一旦有了 block,team lead 会知道这件事情,会帮你赶紧解决这个事情,推进整个项目的进展。
让一个技术人员在团队面前承认这件事情我尝试了几天,的确搞不定了,也是一种压力。
站会中的内容其实在前一天晚上就要开始准备了。
持续集成要求每天都提交代码,这样才能降低代码集成的风险,不能埋头写一周一起提交,这样往往集成不成功。
怎么样才能鼓励团队成员每天都提交代码呢?一个就是第二天的站会,你这个功能代码提交了,单元测试通过了,第二天才能说做完了,否则不算,这就逼得你,将大任务拆成小任务,每天都多次提交。
而且 Git 的提交方式,是后提交者有责任去 merge,保证代码的编译通过和测试通过,你会发现,如果你不及时提交,等你改了一大片代码,别人都提交完了,这一大片的冲突都是你来 merge,测试用例不通过的你来 fix。
所以逼的你有一个小的功能的改动,就尽早提交,pull 一下发现没有人提交,就赶紧提交。
提交不是马上进入主库,而是需要代码审核,这是把控代码质量的重要环节。
代码质量的控制往往每个公司都有文档,甚至你可以从网上下载一篇很长很长的 Java 代码规范。
但是我们常常看到的例子是,规范是有,但是虱子多了不咬人,规范太多的,谁也记不住,等于没有规范。
所以建议将复杂的规范通过项目组内部的讨论,简化为简单的 10 几条军规,深入人心,大家都容易记住,并且容易执行。
代码审核往往需要注意下面的几方面:
代码结构:整个项目组应该规定统一的代码组织结构,使得每个开发拿到另一个人的代码,都能看到熟悉的面孔。
这也是 Scrum 中提倡的每个开发之间是可替代的,当一个模块有了阻碍,其他人是可以帮上忙的。至于核心的逻辑,估计审核人员也来不及细看,这不要紧,核心逻辑是否通过,不能靠眼睛,要靠测试。
有没有注释,尤其是对外的接口,应该有完善的注释,方便自动生成接口文档。
异常的处理,是否抛出太过宽泛的异常,是否吞掉异常,是否吞掉异常的日志等。
对于 pom 是否有修改,引入了新的 jar。
对于配置文件是否有修改,对外访问是否设置超时。
对于数据库是否有修改,是否经过 DBA 审核。
接口实现是否幂等,因为 Dubbo 和 Spring Cloud 都会重试接口。接口是否会升级,是否带版本号。
是否有单元测试。
当然还有一些不容易一眼看出来的,可以通过一段时间通过统一的代码 review,来修改这些问题:
某个类代码长度过长
设计是否合理,高内聚低耦合
数据库设计是否合理
数据库事务是否使用合理
代码是否有明显的阻塞
代码审核完毕提交上去之后,一个是要通过静态代码审查,可以发现一些可能带来代码风险的问题,例如异常过于宽泛等。
再就是要通过单元测试。我们应该要求每个类都要有单元测试,并且单元测试覆盖率要达到一定的指标。单元测试要有带 Mock 的模块内的集成测试。
在编译过程中会触发单元测试,单元测试不通过,以及代码覆盖率,都会统计后发邮件,抄送所有的人,这对于研发来讲又是一个压力。
当有一天你的提交 break 掉了测试,或者代码覆盖率很低,则就像通报批评一样,你需要赶紧去修改。
单元测试完毕之后,就会上传成果物,或者是 war 或者是 jar,一般会用 nexus。
因为有版本号,有 md5,可以保证安装在环境中的就是某个版本的某个包,我们还遇到过有使用 FTP 的,这样很难保证版本号的维护,升级和回滚也比较难弄。
另一个是没有 md5,很可能包不完整都有可能的,而且一旦发生,很难发现。
如果使用了容器,则还需要编译 Dockerfile,使用 Docker 镜像作为交付,能够实现更好的环境一致性,保证原子的升级和回滚。
每天下班前,当天的代码需要提交到库中去,晚上会做一次统一的环境部署和集成测试。
每天晚上凌晨,会有自动化的脚本将 Docker 镜像通过编排部署一个完整的环境,然后跑集成测试用例,集成测试用例应该是基于 API 的,很多的公司是基于 UI 的。
这样由于 UI 变化太快,还有 UI 不能覆盖所有的场景,所以还是建议 UI 和 API 分离。
通过 API 进行集成测试,有了每天的测试,才能保证每天晚上的版本都是可以交付的版本,也保证我们微服务拆分的时候,尽管改了很多,不会因为新的修改,破坏掉原来能够通过的测试用例,保证不会有了新的,坏了旧的。
这个集成测试或者叫回归测试每天晚上都做,都是在一个全新的环境中,这就是持续部署和持续交付。
如果某一天测试不通过,则会发出邮件来,是因为当天谁的哪个提交,导致测试不通过,抄送所有人,这是另一个压力。
所以第二天的站会上,昨天你完成了哪些功能,是否提交了,是否完成了单元测试,是否通过了集成测试,就都知道了,你需要给大家一个解释,然后进入到新一天的开发。
到了两周,一个周期完毕,可以上线到生产环境了,可以通知有权限的运维进行操作,但是也是通过自动化的脚本进行部署的。
这就是整个过程,层层保证质量,从中可以看到,敏捷开发,持续集成,持续交付,持续部署,DevOps 是互相联系的,少了哪个,流程都玩不转。
有关代码结构
代码结构往往包括:
API 接口包
访问外部服务包
数据库 DTO
访问数据库包
服务与商务逻辑
外部服务
如果使用 Dubbo RPC,则 API 接口往往在一个单独的 jar 里面,被服务端和客户端共同依赖。
但是使用了 Spring Cloud 的 restful 方式就不用了,只要在各自的代码里面定义就可以了,会变成 json 的方式传递。
这样的好处是当 jar 有多个版本依赖,需要升级的时候,关系非常复杂,难以维护,而 json 的方式比较好的解决了这个问题。
这个模块提供了哪些接口,只要到 API 接口这个 package 下面找就可以了。因为无论是 Dubbo 还是 Spring Cloud,接口的调用都会重试,因而接口需要实现幂等。
访问外部服务的包,将所有对外的访问独立出来,有三个好处:
可以抽象出来,在服务拆分的时候,可能会用到,例如原来支付的逻辑在下单的模块中,要将支付独立出来,则会有一个抽象层,涉及到老的支付方式,还是调用本模块中的逻辑,涉及到新接入的支付方式使用远程调用,有了这一层方便的多。
可以实现熔断,当被调用的服务不正常的时候,在这里可以返回托底数据。
可以实现 Mock,这样对于单元测试来讲非常好,不用依赖于其他服务,就可以自己进行测试。
DTO 和访问数据库的包,看到了这些数据结构,会帮助程序员快速掌握代码逻辑。
不知道大家有没有这个体验,你去看一个开源软件的代码,首先要看的是它的数据结构,数据结构和关系看懂了,代码逻辑就比较容易懂了,如果数据结构没看懂,光看逻辑,就容易云里雾里。
还有就是核心的代码逻辑和对接口的实现。在这里面是软件代码设计的内功所在,但是却不是流程能够控制的。
有关接口设计规范
上面也说过了,Dubbo 和 Spring Cloud 会对接口进行重试,因而接口需要保持幂等。
也即多次调用,应该产生一致的结果,例如转账 1 元,因为调用失败或者超时重试的时候,最终结果还应该是转账 1 元,而非调用两次变成转账 2 元。
幂等判断尽量提前,可以使用 ID 作为判断条件。
接口的实现应该尽量避免阻塞,可以使用异步方式提升性能。
接口应该包括能够区分不同情况的异常,而非抛出宽泛的 Exception,不能吞掉异常。
接口的实现要有足够的容错性,以及对不同版本的兼容性。当要引入新接口的时候,使用先添加,后删除的方式。
接口应该有良好的注释。
有关代码设计
对于代码的设计,这里常说的就是 SOLID 原则:
S 是单一责任原则,如果你的代码中有一个类行数太长,可能你需要重新审视一下,是不是这个类承担了过多的责任。
O 是开放关闭原则,比较拗口,对扩展开放,对修改关闭。思想是对于代码的直接修改是非常危险的事情,因为你不知道这段代码原来被谁用了,而且到用的时候,面临的情况都是怎样的。
因而不要贸然修改一段代码,而是选择用接口进行调用,用实现进行扩展的方式进行。
当你要实现一段新的功能的时候,不要改原来的代码,也不要 if-else,而是应该扩展一种实现,让原来的调用的代码逻辑还是原来的,在新的情况下使用新实现的代码逻辑。
L 是里氏替换原则,如果基于接口进行编程,则子类一定要能够扩展父类的功能,如果不能,说明不应该继承于这个接口。
例如你在实现的时候,发现接口中有一个方法在你这里实在对应不到实现,不是接口设计的问题,就是你不应该继承这个接口,绝不能出现 not implemented 类似之类的实现方法。
I 是接口隔离原则,接口不应该设计的大而全,一个接口暴露出所有的功能,从而使得客户端依赖了自己不需要的接口或者接口的方法。
而是应该将接口进行细分和提取,而不应该将太过灵活的参数和变量混杂在一个接口中。
D 是依赖倒置原则,A 模块依赖于 B 模块,B 模块有了修改,反而要改 A,就是依赖的过于紧密的问题。
这就是我们常说的,你变了,我没变,为啥我要改。如果基于抽象的接口编程,将修改隐藏在后面,则能够实现依赖的解耦。
以上是模块内部常见的设计原则,对于模块之间,则是对于云原生应用常说的十二原则。
有关配置文件
在代码仓库中,还需要管理的是配置文件,往往在 src/main/resource 下面。
配置的管理原来多使用 profile 进行管理,对于 dev、test、production 使用不同的配置文件。
当配置非常多的时候,比较的痛苦,而且配置不断的修改,每次上线各种配置需要仔细的核对,眼睛都花了,才敢上线。
我们可以将配置分为下面的三类:
内部配置项(启动后不变,改变需要重启)
集中配置项(配置中心,可动态下发)
外部配置项(外部依赖,和环境相关)
在梳理配置的时候,可以按着三类归类,分门别类管理。
在使用了容器之后,很多的内部配置项可固化在配置文件中,放在容器镜像中,需要启动的时候修改的,则通过环境变量,在启动容器的时候,在编排文件中进行修改。
依赖的内部服务的地址,在容器平台 Kubernetes 里面,可以通过配置服务名进行服务发现,仅仅在配置文件中配置名称就可以了,不用配置真实的地址。
Kubernetes 可以根据不同的环境,不同的 namespace 自动关联好,大大简化了配置。当然也可以用服务中心 Dubbo 和 Spring Cloud 做内部服务的相互发现。
依赖的外部服务的地址,例如 MySQL、Redis 等,往往不同的环境,也可以通过配置 Kubernetes 外部服务名的方式进行,而不用一一核对,担心测试环境连上了生产环境的 IP 地址。
还有一些集中配置项,需要动态修改的,例如限流,降级的开关等,需要通过统一的配置中心进行管理。
有关数据库版本
代码可以很好的版本化,应用也可以用镜像进行原子化的升级和回滚。
唯一比较难做到的就是数据库如何版本化管理。有一个工具 Flyway 可以比较好的做这件事情。
在代码中,Flyway 需要有以下的结构:
在 src/db/migration 中有 SQL 文件,命名规则,如:V1__2017_4_13.sql ,V 开头+版本号+双下划线+描述,后缀为 sql。
增加 Flyway 的 Java 类,实现 migration 方法。
在数据库中,Flyway 会自动增加 SCHEME_VERSION 表。
当服务启动的时候,Java 类的 migration 方法会被调用,它会按照指定路径中 sql 语句的版本号进行排序并且按照这个排序去执行,当每一个 SQL 文件被执行后,元数据的表就会按照格式进行更新。
当服务重启的时候,Flyway 再次扫描 SQL 的时候,它就会检查元数据表中迁移版本,如果要执行的迁移脚本的版本小于或者等于当前版本,Flyway 将会忽略,不再重复执行。
但是 Flyway 从来不解决数据库升级和回滚的代码兼容性问题。
太多的人问这个问题了,代码可以灰度发布,数据库咋灰度?代码升级了,发现不对可以回滚,数据库咋回滚。
如果可以停服的话,自然是使用数据库快照备份的方式进行回滚了。
如果不可以停服,没办法,只有在代码层面做兼容性。每次涉及数据库升级的都是大事情,代码当然应该有个开关,保证随时可以切回原来的逻辑。
作者:刘超
编辑:陶家龙、孙淑娟
来源:转载自刘超的通俗云计算微信公众号
精彩文章推荐: